Skip to content

feat: add server-synced user preferences infrastructure (#484)#1189

Open
gusa4grr wants to merge 5 commits intonpmx-dev:mainfrom
gusa4grr:feat-484-persist-user-preferences
Open

feat: add server-synced user preferences infrastructure (#484)#1189
gusa4grr wants to merge 5 commits intonpmx-dev:mainfrom
gusa4grr:feat-484-persist-user-preferences

Conversation

@gusa4grr
Copy link
Contributor

@gusa4grr gusa4grr commented Feb 8, 2026

Note: I came from a React background, quite a newbie to the Nuxt/Vue ecosystem. Please let me know if any patterns are misplaced. Happy to learn and adjust!

Core implementation details:

  • created a user-preference store factory
  • created a settings sync logic with 2 seconds debounce
  • added an interceptor to trigger update requests on route changes and beforeunload. (or we can remove this if it is deemed unnecessary overhead).
  • extract non user-preference settings into separate useUserLocalSettings composable
  • add preferences-sync.client.ts plugin for early color mode + server sync init
  • wrap theme select in <ClientOnly> to prevent SSR hydration mismatch
  • show sync status indicator on settings page for authenticated users
  • add useColorModePreference composable to sync color mode with @nuxtjs/color-mode
  • split user preferences related composables into separate files

ToDo:

  • add support for searchProvider, which was added while this MR was open
  • add support for connector, which was added while this MR was open
  • fully test locally all scenarios
  • properly add localization (if not yet proper 😅 )
  • preload and hydrate settings properly (advice needed. See questions below)

Questions:

  • Do we want to cleanup "old" KV pairs from npmx-settings LS, as those will now live in separate place?
  • I used client.ts suffix for useUserPreferencesSync.client.ts to ensure it is client-side only. Is this the standard convention to prevent server-side execution?
  • with this new logic, we need to fetch user preferences immediately upon/before the app loads. what is the recommended approach for this? I created the preferences-sync.client.ts plugin for now

I noticed initPreferencesOnPrehydrate, which retrieves some settings from LS on the client, but it doesn't appear to support data fetching. Few other places also using onPrehydrate.
I am curious as we can load preferences during SSR too and can hydrate client with the preferences right away (if logged in). What files/places should I look at, any suggestions?

Closes #484

Needs to be discussed - Done ✅

During the implementation, I identified inconsistent local storage usage across the app:

image
  • npmx-color-mode - used for color mode. It is adjustable via the settings page, but is also evaluated by lighthouse and referenced in the nuxt.config colorMode property
  • npmx-list-prefs - used in search to modify the viewing experience
  • npmx-settings - contains settings found in /settings route as well as unrelated sidebar states on package page (see image below)
image

Based on the feature requirements, I decided to create the user-preferences schema specifically for the /settings page configuration. However, the current useSettings hook combines both user-preferences and "sidebar states".

I want to align with the team on the execution strategy before finalizing these changes.

Proposed plan:

  • color-mode - retain as-is (npmx-color-mode), but connect to user-preference service
  • npmx-list-prefs - retain as-is for now (or potentially merge into npmx-settings). We can add this to the user-preferences service as a follow-up and remove this LS key then.
  • npmx-settings - remove all user-preference related keys (leaving only the sidebar state). Merge with npmx-settings or leave as-is
  • npmx-user-preferences - new LS key for user preferences. This will serve as the source of truth for anonymous users. For logged-in users, it will sync with the server, where the server state takes precedence on load

The solution I am drafting centralizes these options into a single user-preference service. However, if we include items outside of /settings, we need to consider:

  • making it clear to the user what parts are persistent settings
  • defining the correct "sync" lifecycle

@vercel
Copy link

vercel bot commented Feb 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 16, 2026 2:10pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 16, 2026 2:10pm
npmx-lunaria Ignored Ignored Feb 16, 2026 2:10pm

Request Review

@43081j
Copy link
Contributor

43081j commented Feb 8, 2026

haven't reviewed the code yet, but on the proposed changes:

  • color-mode - i agree leave it where it is but possibly sync it
  • npmx-list-prefs - keep it clientside since it is likely to be changed often between views
  • npmx-settings - agree
  • npmx-user-preferences - agree

The usual pattern is this:

  • Anything that persists across pages, sync it to the backend
  • Anything that is one-time, temporary, or often toggled (like search filters), do not sync

we should then end up in a state where our prefs save to local storage and take effect immediately, but meanwhile get synced to the server in the background.

i think we probably also need a toggle somewhere to disable sync, in case you want different prefs per machine. same way chrome works today

@github-actions
Copy link

github-actions bot commented Feb 9, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@gusa4grr
Copy link
Contributor Author

gusa4grr commented Feb 9, 2026

@43081j 👋🏻

Updated the MR description and pushed one more commit.
I'll leave it as a draft for now, as I haven't validated all the scenarios, but the core things are working fine!

if you have time - please look throught the code 🙂 Much appreciated!

I will try to find time during the work week to finalise this, as not much is left 🤞🏻

}
}

export function useRelativeDates() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably split these up into app/composables/preferences/whatever.ts

e.g. app/composables/preferences/useRelativeDates.ts

to follow convention of the rest of the repo

- introduce the foundational layer for persisting user preferences
to the server
- add UserPreferencesSchema and shared types for user preferences
- add client-only sync composable with debounced saves, route guard flush, and sendBeacon fallback
- integrate server sync into useSettings and migrate to shared UserPreferences type
- extract generic localStorage helpers, migrate consumers, remove usePreferencesProvider
- extract sidebar collapsed state into separate `usePackageSidebarPreferences` composable
- add `preferences-sync.client.ts` plugin for early color mode + server sync init
- wrap theme select in `<ClientOnly>` to prevent SSR hydration mismatch
- show sync status indicator on settings page for authenticated users
- add `useColorModePreference` composable to sync color mode with `@nuxtjs/color-mode`
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 15, 2026

📝 Walkthrough

Walkthrough

Replaces the legacy useSettings with a split preferences model: a client-only useUserLocalSettings for ephemeral UI state and a server-backed user preferences system (provider, sync client, server APIs, KV-backed store, and shared schema). Adds localStorage/storage abstractions and many userPreferences composables (accent color, background theme, color mode, search provider, relative dates), updates components and tests to consume the new APIs, removes useSettings and usePreferencesProvider, and adds a client plugin to initialise preference sync and apply stored colour mode.

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description comprehensively explains the implementation approach, including infrastructure for persisting and syncing user preferences, new composables, plugins, and detailed TODOs and questions about architecture decisions.
Linked Issues check ✅ Passed The PR successfully implements the core requirement from #484: server-side persistence of settings from the /settings page bound to authenticated users via a KV store, with client-side localStorage caching and background synchronisation.
Out of Scope Changes check ✅ Passed Changes are focused on user preferences infrastructure. Minor scope items include sidebar state extraction and colour mode preference composables, which are closely related to the preference management system and support the overall objective.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
app/composables/userPreferences/useUserPreferencesState.ts (1)

1-9: Align the “read-only” wording with the mutable return value.

The docstring promises read-only access, but the returned ref is writable. Either clarify the wording or enforce immutability so expectations are consistent.

shared/schemas/userPreferences.ts (1)

35-38: Type definitions could drift from schema.

The types AccentColorId and BackgroundThemeId are derived from the constants, whilst ColorModePreference and SearchProvider are manually defined literals. Consider deriving these from the schema for consistency:

♻️ Suggested approach
+import type { InferOutput } from 'valibot'
+
+// Derive types from schemas to prevent drift
+export type ColorModePreference = InferOutput<typeof ColorModePreferenceSchema>
+export type SearchProvider = InferOutput<typeof SearchProviderSchema>
 export type AccentColorId = keyof typeof ACCENT_COLORS.light
 export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES
-export type ColorModePreference = 'light' | 'dark' | 'system'
-export type SearchProvider = 'npm' | 'algolia'
app/composables/useUserPreferencesSync.client.ts (2)

137-144: Route guard uses fire-and-forget pattern which may lose data.

The void flushPendingSync() call allows navigation to proceed without awaiting the save. If the network request fails or is slow, the user may navigate away before their preferences are persisted.

Consider awaiting the flush to ensure data is saved before navigation:

♻️ Suggested fix
   function setupRouteGuard(getPreferences: () => UserPreferences): void {
     router.beforeEach(async (_to, _from, next) => {
       if (hasPendingChanges && isAuthenticated.value) {
-        void flushPendingSync(getPreferences())
+        await flushPendingSync(getPreferences())
       }
       next()
     })
   }

32-41: Silent error handling may mask connectivity issues.

fetchServerPreferences catches all errors and returns null, making it indistinguishable from "user has no saved preferences" vs "network/server error". Consider logging errors or updating the sync state to reflect fetch failures.

server/utils/preferences/user-preferences-store.ts (1)

23-35: Minor: Double timestamp assignment in merge.

Line 30 sets updatedAt, then line 33 calls this.set() which sets updatedAt again on line 18. This is functionally correct but slightly redundant.

♻️ Optional simplification
   async merge(did: string, partial: Partial<UserPreferences>): Promise<UserPreferences> {
     const existing = await this.get(did)
     const base = existing ?? { ...DEFAULT_USER_PREFERENCES }

     const merged: UserPreferences = {
       ...base,
       ...partial,
-      updatedAt: new Date().toISOString(),
     }

-    await this.set(did, merged)
+    await this.storage.setItem(did, {
+      ...merged,
+      updatedAt: new Date().toISOString(),
+    })
     return merged
   }
app/composables/useUserPreferencesProvider.ts (1)

77-95: Avoid echoing server‑loaded prefs back to the server.
In the auth watcher, assigning preferences.value triggers the deep watch and schedules a sync, even though the data just came from the server. This creates redundant PUTs and can churn timestamps. Consider suppressing scheduleSync while applying server prefs.

♻️ Suggested refactor
   const isSyncing = computed(() => status.value === 'syncing')
   const isSynced = computed(() => status.value === 'synced')
   const hasError = computed(() => status.value === 'error')
+  let isApplyingServerPrefs = false

   async function initSync(): Promise<void> {
@@
     watch(
       preferences,
       newPrefs => {
-        if (isAuthenticated.value) {
+        if (isAuthenticated.value && !isApplyingServerPrefs) {
           scheduleSync(newPrefs)
         }
       },
       { deep: true },
     )

     watch(isAuthenticated, async newIsAuth => {
       if (newIsAuth) {
         const serverPrefs = await loadFromServer()
         if (serverPrefs) {
           const merged = { ...defaultValue, ...preferences.value, ...serverPrefs }
           if (!arePreferencesEqual(preferences.value, merged)) {
-            preferences.value = merged
+            isApplyingServerPrefs = true
+            preferences.value = merged
+            isApplyingServerPrefs = false
           }
         }
       }
     })

Comment on lines +11 to +33
export function useLocalStorageHashProvider<T extends object>(key: string, defaultValue: T) {
const provider = createLocalStorageProvider<T>(key)
const data = ref<T>(defaultValue)

onMounted(() => {
const stored = provider.get()
if (stored) {
data.value = defu(stored, defaultValue)
}
})

function save() {
provider.set(data.value)
}

function reset() {
data.value = { ...defaultValue }
provider.remove()
}

function update<K extends keyof T>(key: K, value: T[K]) {
data.value[key] = value
save()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent shared default mutations in the storage provider

data is initialised with defaultValue by reference and reset() only shallow‑clones, so nested arrays/objects can mutate the defaults and make resets or new instances pick up modified values. Deep‑clone defaults on init/reset and when merging stored values.

🛠️ Suggested fix
 export function useLocalStorageHashProvider<T extends object>(key: string, defaultValue: T) {
   const provider = createLocalStorageProvider<T>(key)
-  const data = ref<T>(defaultValue)
+  const data = ref<T>(structuredClone(defaultValue))

   onMounted(() => {
     const stored = provider.get()
     if (stored) {
-      data.value = defu(stored, defaultValue)
+      data.value = defu(stored, structuredClone(defaultValue))
     }
   })

   function reset() {
-    data.value = { ...defaultValue }
+    data.value = structuredClone(defaultValue)
     provider.remove()
   }

Comment on lines +60 to +75
async function initSync(): Promise<void> {
if (syncInitialized || import.meta.server) return
syncInitialized = true

setupRouteGuard(() => preferences.value)
setupBeforeUnload(() => preferences.value)

if (isAuthenticated.value) {
const serverPrefs = await loadFromServer()
if (serverPrefs) {
const merged = { ...preferences.value, ...serverPrefs }
if (!arePreferencesEqual(preferences.value, merged)) {
preferences.value = merged
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent initSync from getting stuck on load failures.
If loadFromServer() throws (offline/5xx), initSync exits before watchers/guards are registered, yet syncInitialized stays true, so subsequent calls no‑op. Wrap the fetch in a try/catch and ensure setup continues even on failure (or reset the flag).

🛠️ Suggested fix
   async function initSync(): Promise<void> {
     if (syncInitialized || import.meta.server) return
     syncInitialized = true

     setupRouteGuard(() => preferences.value)
     setupBeforeUnload(() => preferences.value)

-    if (isAuthenticated.value) {
-      const serverPrefs = await loadFromServer()
-      if (serverPrefs) {
-        const merged = { ...preferences.value, ...serverPrefs }
-        if (!arePreferencesEqual(preferences.value, merged)) {
-          preferences.value = merged
-        }
-      }
-    }
+    if (isAuthenticated.value) {
+      try {
+        const serverPrefs = await loadFromServer()
+        if (serverPrefs) {
+          const merged = { ...preferences.value, ...serverPrefs }
+          if (!arePreferencesEqual(preferences.value, merged)) {
+            preferences.value = merged
+          }
+        }
+      } catch (_err) {
+        // allow init to complete; sync status is handled by useUserPreferencesSync
+      }
+    }

As per coding guidelines: “Use error handling patterns consistently.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function initSync(): Promise<void> {
if (syncInitialized || import.meta.server) return
syncInitialized = true
setupRouteGuard(() => preferences.value)
setupBeforeUnload(() => preferences.value)
if (isAuthenticated.value) {
const serverPrefs = await loadFromServer()
if (serverPrefs) {
const merged = { ...preferences.value, ...serverPrefs }
if (!arePreferencesEqual(preferences.value, merged)) {
preferences.value = merged
}
}
}
async function initSync(): Promise<void> {
if (syncInitialized || import.meta.server) return
syncInitialized = true
setupRouteGuard(() => preferences.value)
setupBeforeUnload(() => preferences.value)
if (isAuthenticated.value) {
try {
const serverPrefs = await loadFromServer()
if (serverPrefs) {
const merged = { ...preferences.value, ...serverPrefs }
if (!arePreferencesEqual(preferences.value, merged)) {
preferences.value = merged
}
}
} catch (_err) {
// allow init to complete; sync status is handled by useUserPreferencesSync
}
}
}

Comment on lines +18 to +21
// Read user preferences from localStorage
const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')

const accentColorId = settings.accentColorId
const accentColorId = preferences.accentColorId
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard JSON.parse to avoid prehydrate crashes on malformed localStorage.

If a user (or browser) stores invalid JSON, the current parse will throw and short‑circuit the prehydrate logic. You already guard other parsing (package manager), so mirroring that here keeps behaviour consistent and resilient.

🛠️ Suggested hardening
     // Read user preferences from localStorage
-    const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
+    const safeParse = (raw) => {
+      if (!raw) return {}
+      try {
+        const parsed = JSON.parse(raw)
+        return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
+      } catch {
+        return {}
+      }
+    }
+    const preferences = safeParse(localStorage.getItem('npmx-user-preferences'))
@@
-    const sidebar = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
-    document.documentElement.dataset.collapsed = sidebar.sidebar?.collapsed?.join(' ') ?? ''
+    const sidebar = safeParse(localStorage.getItem('npmx-settings'))
+    document.documentElement.dataset.collapsed =
+      Array.isArray(sidebar.sidebar?.collapsed) ? sidebar.sidebar.collapsed.join(' ') : ''

As per coding guidelines, “Use error handling patterns consistently”.

Also applies to: 53-54

@gusa4grr gusa4grr force-pushed the feat-484-persist-user-preferences branch from e56d0d9 to 21882ac Compare February 16, 2026 12:32
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
app/pages/settings.vue (1)

66-100: Remove the non‑essential template comment.
Line 66 is descriptive rather than explaining complex logic; consider dropping it to keep templates lean.

♻️ Suggested tidy‑up
-        <!-- Sync status indicator for authenticated users -->
As per coding guidelines: “Add comments only to explain complex logic or non-obvious implementations”.
test/nuxt/components/HeaderConnectorModal.spec.ts (1)

104-106: Reset the whole mockUserLocalSettings object to avoid cross‑test leakage.
Only connector is reset; if future tests mutate sidebar, state will bleed between tests.

Proposed fix
 function resetMockState() {
   mockState.value = {
     connected: false,
     connecting: false,
     npmUser: null,
     avatar: null,
     operations: [],
     error: null,
     lastExecutionTime: null,
   }
-  mockUserLocalSettings.value.connector = {
-    autoOpenURL: false,
-  }
+  mockUserLocalSettings.value = {
+    sidebar: {
+      collapsed: [],
+    },
+    connector: {
+      autoOpenURL: false,
+    },
+  }
 }

Comment on lines 4 to +6
onPrehydrate(el => {
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
const id = settings.accentColorId
const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
const id = preferences.accentColorId
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard JSON.parse in onPrehydrate to prevent crashes.

If localStorage.getItem('npmx-user-preferences') contains malformed JSON, the parse will throw and break the prehydrate logic. Wrap in try/catch for resilience.

🛠️ Suggested fix
 onPrehydrate(el => {
-  const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
-  const id = preferences.accentColorId
+  let id = null
+  try {
+    const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
+    id = preferences?.accentColorId
+  } catch {
+    // Ignore malformed JSON
+  }
   if (id) {

As per coding guidelines, "Use error handling patterns consistently".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onPrehydrate(el => {
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
const id = settings.accentColorId
const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
const id = preferences.accentColorId
onPrehydrate(el => {
let id = null
try {
const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
id = preferences?.accentColorId
} catch {
// Ignore malformed JSON
}

Comment on lines +19 to +26
function setAccentColor(id: AccentColorId | null) {
if (id) {
document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
} else {
document.documentElement.style.removeProperty('--accent-color')
}
preferences.value.accentColorId = id
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard DOM access for SSR safety.

Similar to useBackgroundTheme, setAccentColor directly accesses document.documentElement.style, which will throw during SSR.

🛠️ Suggested fix
 function setAccentColor(id: AccentColorId | null) {
+  if (import.meta.server) {
+    preferences.value.accentColorId = id
+    return
+  }
   if (id) {
     document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
   } else {
     document.documentElement.style.removeProperty('--accent-color')
   }
   preferences.value.accentColorId = id
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function setAccentColor(id: AccentColorId | null) {
if (id) {
document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
} else {
document.documentElement.style.removeProperty('--accent-color')
}
preferences.value.accentColorId = id
}
function setAccentColor(id: AccentColorId | null) {
if (import.meta.server) {
preferences.value.accentColorId = id
return
}
if (id) {
document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
} else {
document.documentElement.style.removeProperty('--accent-color')
}
preferences.value.accentColorId = id
}

Comment on lines +13 to +20
function setBackgroundTheme(id: BackgroundThemeId | null) {
if (id) {
document.documentElement.dataset.bgTheme = id
} else {
document.documentElement.removeAttribute('data-bg-theme')
}
preferences.value.preferredBackgroundTheme = id
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard DOM access for SSR safety.

setBackgroundTheme directly accesses document.documentElement, which will throw during SSR. Either guard the DOM manipulation or ensure this composable is only ever called client-side.

🛠️ Suggested fix
 function setBackgroundTheme(id: BackgroundThemeId | null) {
+  if (import.meta.server) {
+    preferences.value.preferredBackgroundTheme = id
+    return
+  }
   if (id) {
     document.documentElement.dataset.bgTheme = id
   } else {
     document.documentElement.removeAttribute('data-bg-theme')
   }
   preferences.value.preferredBackgroundTheme = id
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function setBackgroundTheme(id: BackgroundThemeId | null) {
if (id) {
document.documentElement.dataset.bgTheme = id
} else {
document.documentElement.removeAttribute('data-bg-theme')
}
preferences.value.preferredBackgroundTheme = id
}
function setBackgroundTheme(id: BackgroundThemeId | null) {
if (import.meta.server) {
preferences.value.preferredBackgroundTheme = id
return
}
if (id) {
document.documentElement.dataset.bgTheme = id
} else {
document.documentElement.removeAttribute('data-bg-theme')
}
preferences.value.preferredBackgroundTheme = id
}

Comment on lines +9 to +15
const { preferences } = useUserPreferencesState()
const settingsLocale = preferences.value.selectedLocale

if (
settingsLocale &&
// Check if the value is a supported locale
locales.value.map(l => l.code).includes(settingsLocale) &&
// Check if the value is not a current locale
settingsLocale !== locale.value
) {
setLocale(settingsLocale)
const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale)

if (matchedLocale && matchedLocale !== locale.value) {
setLocale(matchedLocale)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against unset preferences during early boot.
If preferences.value can be null/undefined before hydration, Line 10 will throw and prevent locale initialisation. A small null-guard keeps this safe.

Proposed fix
-    const settingsLocale = preferences.value.selectedLocale
+    const settingsLocale = preferences.value?.selectedLocale
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { preferences } = useUserPreferencesState()
const settingsLocale = preferences.value.selectedLocale
if (
settingsLocale &&
// Check if the value is a supported locale
locales.value.map(l => l.code).includes(settingsLocale) &&
// Check if the value is not a current locale
settingsLocale !== locale.value
) {
setLocale(settingsLocale)
const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale)
if (matchedLocale && matchedLocale !== locale.value) {
setLocale(matchedLocale)
const { preferences } = useUserPreferencesState()
const settingsLocale = preferences.value?.selectedLocale
const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale)
if (matchedLocale && matchedLocale !== locale.value) {
setLocale(matchedLocale)

Comment on lines +220 to +222
// Get preferences and disable includeTypesInInstall directly
const { preferences } = useUserPreferencesState()
preferences.value.includeTypesInInstall = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid leaking preference state across tests.
useUserPreferencesProvider caches a module‑level ref, so mutating includeTypesInInstall here can persist into later tests even if localStorage is cleared. Reset it after the assertion (or in an afterEach) to keep tests isolated.

🧹 Suggested fix
       expect(showTypesInInstall.value).toBe(false)
       expect(fullInstallCommand.value).toBe('npm install express')
+      preferences.value.includeTypesInInstall = true

Comment on lines +31 to +62
it('initializes with default values when storage is empty', () => {
mountWithSetup(() => {
const { preferences } = usePackageListPreferences()
onMounted(() => {
expect(preferences.value).toEqual(DEFAULT_PREFERENCES)
})
})
})

it('loads and merges values from localStorage', () => {
mountWithSetup(() => {
const stored = { viewMode: 'table' }
setLocalStorage(stored)
const { preferences } = usePackageListPreferences()
onMounted(() => {
expect(preferences.value.viewMode).toBe('table')
expect(preferences.value.paginationMode).toBe(DEFAULT_PREFERENCES.paginationMode)
expect(preferences.value.pageSize).toBe(DEFAULT_PREFERENCES.pageSize)
expect(preferences.value.columns).toEqual(DEFAULT_PREFERENCES.columns)
})
})
})

it('handles array merging by replacement', () => {
mountWithSetup(() => {
const stored = { columns: [] }
setLocalStorage(stored)
const { preferences } = usePackageListPreferences()
onMounted(() => {
expect(preferences.value.columns).toEqual([])
})
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assertions inside onMounted are not awaited; tests can silently pass.
The test body finishes before onMounted runs, so failures may be missed. Consider awaiting nextTick() and asserting in the test body.

Suggested pattern (apply similarly to other tests)
-import { defineComponent, onMounted } from 'vue'
+import { defineComponent, nextTick } from 'vue'

-function mountWithSetup(run: () => void) {
-  return mount(
+async function mountWithSetup<T>(setupFn: () => T) {
+  let result: T
+  const wrapper = mount(
     defineComponent({
       name: 'TestHarness',
       setup() {
-        run()
+        result = setupFn()
         return () => null
       },
     }),
     { attachTo: document.body },
   )
+  await nextTick()
+  return { wrapper, result: result! }
 }

-it('initializes with default values when storage is empty', () => {
-  mountWithSetup(() => {
-    const { preferences } = usePackageListPreferences()
-    onMounted(() => {
-      expect(preferences.value).toEqual(DEFAULT_PREFERENCES)
-    })
-  })
-})
+it('initializes with default values when storage is empty', async () => {
+  const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences())
+  expect(result.preferences.value).toEqual(DEFAULT_PREFERENCES)
+  wrapper.unmount()
+})

onPrehydrate(el => {
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
const id = settings.preferredBackgroundTheme
const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here too we should try/catch the parse call and probably just fall back to {} like we do if it isn't set

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/nuxt/components/compare/PackageSelector.spec.ts (1)

112-113: ⚠️ Potential issue | 🟡 Minor

Guard against undefined before calling trigger.

The .find() method on an array can return undefined if no element matches. Using ! without a preceding check could cause a runtime error if the component's markup changes and no button contains the expected icon class.

Suggested fix
       const removeButton = component.findAll('button').find(b => b.find('.i-lucide\\:x').exists())
+      expect(removeButton).toBeDefined()
       await removeButton!.trigger('click')

As per coding guidelines: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".

@@ -0,0 +1,4 @@
export function useRelativeDates() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this should also be called useRelativeDatesPreference?

since nuxt magic will make it global, so the name might end up ambiguous in other contexts


function arePreferencesEqual(a: UserPreferences, b: UserPreferences): boolean {
const keys = Object.keys(DEFAULT_USER_PREFERENCES) as (keyof typeof DEFAULT_USER_PREFERENCES)[]
return keys.every(key => a[key] === b[key])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we ever add or remove a key? should we also check the length here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Persisted user preferences

2 participants